#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os, sys, json, shutil, argparse
from pathlib import Path
from datetime import datetime
from typing import Optional, List, Tuple, Any

# -------- i18n --------
def detect_lang(cli_lang: Optional[str]) -> str:
    if cli_lang:
        return "zh" if cli_lang.lower().startswith("zh") else "en"
    env = os.environ.get("LANG", "")
    return "zh" if env.lower().startswith("zh") else "en"

TEXT = {
    "en": {
        "desc": "Safe symlink manager from JSON config (create/update/unlink/check).",
        "cfg_arg": "Path to JSON config file.",
        "dry": "Dry-run: print actions only.",
        "force": "Overwrite existing targets instead of backing up.",
        "backup": "Backup root (default: ~/.dotfiles-backup). Set '' to disable.",
        "relative": "Create relative symlinks (default).",
        "absolute": "Create absolute symlinks (overrides default relative).",
        "unlink": "Unlink symlinks listed in config (safe by default).",
        "unsafe_unlink": "Do not verify link target points to repo/src (dangerous).",
        "keep": "Keep going on errors; report at the end.",
        "tmpl": "Print JSON config template and exit.",
        "lang": "Output language (zh/en).",
        "only": "Only process entries whose DEST starts with this prefix (can repeat).",
        "exclude": "Exclude entries whose DEST starts with this prefix (can repeat).",
        "check": "Check status without changes (OK/MISSING_SRC/MISSING_DST/WRONG_TARGET/NOT_SYMLINK/BROKEN_LINK).",
        "prune_broken": "Remove broken symlinks listed in config.",
        "normalize": "Rewrite symlink even if target matches but form differs (abs<->rel).",
        "missing_cfg": "config not found",
        "bad_item_pair": "bad item (expect [src, dest])",
        "bad_item_obj": "bad item (need src/dest)",
        "bad_format": "unsupported JSON format; expected list of pairs or list of objects.",
        "src_missing": "source not found",
        "mkdir": "mkdir -p",
        "backup_from_to": "backup",
        "remove": "remove",
        "skip_ok": "skip (already linked)",
        "skip_not_symlink": "skip (not a symlink)",
        "unlink_do": "unlink",
        "symlink_do": "symlink",
        "done": "done.",
        "summary_errors": "Completed with errors.",
        "status": "status",
        "ok": "OK",
        "missing_src": "MISSING_SRC",
        "missing_dst": "MISSING_DST",
        "wrong_target": "WRONG_TARGET",
        "not_symlink": "NOT_SYMLINK",
        "broken_link": "BROKEN_LINK",
        "skip_unlink_unsafe": "skip (unsafe unlink blocked)",
    },
    "zh": {
        "desc": "基于 JSON 配置的安全软链接管理（创建/更新/删除/体检）。",
        "cfg_arg": "JSON 配置文件路径。",
        "dry": "预演：仅打印动作，不做修改。",
        "force": "覆盖现有目标（默认会先备份）。",
        "backup": "备份根目录（默认：~/.dotfiles-backup）。设为 '' 关闭。",
        "relative": "创建相对软链（默认）。",
        "absolute": "创建绝对软链（覆盖默认的相对软链）。",
        "unlink": "删除配置中列出的软链接（默认开启安全校验）。",
        "unsafe_unlink": "不校验链接目标是否指向仓库/源（危险）。",
        "keep": "出错后继续执行，最后汇总错误。",
        "tmpl": "输出示例 JSON 配置并退出。",
        "lang": "输出语言（zh/en）。",
        "only": "仅处理 DEST 前缀匹配的条目（可多次传入）。",
        "exclude": "排除 DEST 前缀匹配的条目（可多次传入）。",
        "check": "体检模式（仅检查状态：OK/MISSING_SRC/MISSING_DST/WRONG_TARGET/NOT_SYMLINK/BROKEN_LINK）。",
        "prune_broken": "删除配置中列出的坏软链。",
        "normalize": "当目标正确但相对/绝对形式不一致时，重写软链。",
        "missing_cfg": "找不到配置文件",
        "bad_item_pair": "格式错误（期望 [src, dest]）",
        "bad_item_obj": "格式错误（需要 src/dest）",
        "bad_format": "不支持的 JSON 格式；期望二维数组或对象数组。",
        "src_missing": "源不存在",
        "mkdir": "创建目录",
        "backup_from_to": "备份",
        "remove": "删除",
        "skip_ok": "跳过（已是正确软链）",
        "skip_not_symlink": "跳过（不是软链）",
        "unlink_do": "解除链接",
        "symlink_do": "创建软链",
        "done": "完成。",
        "summary_errors": "执行完成，但存在错误。",
        "status": "状态",
        "ok": "OK",
        "missing_src": "源缺失",
        "missing_dst": "目标缺失",
        "wrong_target": "目标不匹配",
        "not_symlink": "非软链",
        "broken_link": "坏软链",
        "skip_unlink_unsafe": "跳过（触发安全校验，未执行删除）",
    },
}

# -------- logging --------
class Logger:
    def __init__(self): pass
    def info(self, msg:str): print(f"[INFO] {msg}")
    def warn(self, msg:str): print(f"[WARN] {msg}")
    def err (self, msg:str): print(f"[ERR]  {msg}")

# -------- utils --------
def abspath_expand(p: str) -> Path:
    return Path(os.path.abspath(os.path.expanduser(p)))

def ensure_dir(p: Path, dry: bool, log: Logger, T: dict):
    if not p.exists():
        log.info(f"{T['mkdir']} {p}")
        if not dry: p.mkdir(parents=True, exist_ok=True)

def backup_target(dst: Path, backup_root: Path, dry: bool, log: Logger, T: dict):
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    rel = dst.as_posix().lstrip("/")
    backup_path = backup_root / ts / rel
    log.info(f"{T['backup_from_to']}: {dst} -> {backup_path}")
    if not dry:
        backup_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(dst.as_posix(), backup_path.as_posix())

def same_symlink(dst: Path, expected_target: Path) -> bool:
    if not dst.is_symlink(): return False
    try:
        current = Path(os.readlink(dst))
    except OSError:
        return False
    if not current.is_absolute():
        current = (dst.parent / current).resolve()
    return current == expected_target.resolve()

def resolve_link_target(dst: Path) -> Optional[Path]:
    if not dst.is_symlink(): return None
    try:
        link = Path(os.readlink(dst))
        if not link.is_absolute():
            link = (dst.parent / link).resolve()
        return link
    except OSError:
        return None

# -------- config --------
def load_pairs(config_file: Path, T: dict) -> List[Tuple[str, str]]:
    with config_file.open("r", encoding="utf-8") as f:
        data: Any = json.load(f)
    pairs: List[Tuple[str, str]] = []
    if isinstance(data, list) and (not data or isinstance(data[0], list)):
        for item in data:
            if not isinstance(item, list) or len(item) != 2:
                raise ValueError(f"{T['bad_item_pair']}: {item}")
            pairs.append((item[0], item[1]))
    elif isinstance(data, list) and data and isinstance(data[0], dict):
        for item in data:
            src = item.get("src"); dst = item.get("dest") or item.get("dst")
            if not src or not dst:
                raise ValueError(f"{T['bad_item_obj']}: {item}")
            pairs.append((src, dst))
    else:
        raise ValueError(T["bad_format"])
    return pairs

def generate_template() -> list:
    return [
        ["./path/in/repo/nvim", "~/.config/nvim"],
        ["./path/in/repo/fish", "~/.config/fish"],
        ["./.gitconfig", "~/.gitconfig"],
    ]

# -------- actions --------
def link_one(src: Path, dst: Path, *, dry: bool, force: bool, backup_dir: Optional[Path],
             make_relative: bool, log: Logger, T: dict):
    if not src.exists():
        raise FileNotFoundError(f"{T['src_missing']}: {src}")

    if dst.exists() or os.path.lexists(dst):
        if same_symlink(dst, src):
            log.info(f"{T['skip_ok']}: {dst} -> {src}")
            return

    ensure_dir(dst.parent, dry, log, T)

    if dst.exists() or os.path.lexists(dst):
        if backup_dir and not force:
            backup_target(dst, backup_dir, dry, log, T)
        else:
            log.info(f"{T['remove']}: {dst}")
            if not dry:
                if dst.is_dir() and not dst.is_symlink():
                    shutil.rmtree(dst.as_posix())
                else:
                    try: dst.unlink()
                    except FileNotFoundError: pass

    if make_relative:
        try:
            target = os.path.relpath(src, start=dst.parent)
        except Exception:
            target = src.as_posix()
    else:
        target = src.as_posix()

    log.info(f"{T['symlink_do']}: {dst} -> {target}")
    if not dry: os.symlink(target, dst)

def safe_unlink(dst: Path, src: Path, repo_root: Path, *, dry: bool,
                unsafe: bool, log: Logger, T: dict):
    if not (dst.is_symlink() or os.path.lexists(dst)):
        return
    if not dst.is_symlink():
        log.warn(f"{T['skip_not_symlink']}: {dst}")
        return
    # 安全校验：链接目标必须是 src，或位于仓库根目录内
    target = resolve_link_target(dst)
    if not unsafe:
        try:
            if target is None:
                # 坏链：允许删除
                pass
            else:
                r = target.resolve()
                in_repo = repo_root in r.parents or r == repo_root
                same_src = r == src.resolve()
                if not (in_repo or same_src):
                    log.warn(f"{T['skip_unlink_unsafe']}: {dst} -> {target}")
                    return
        except Exception:
            # 无法解析目标时视作坏链，允许删除
            pass
    log.info(f"{T['unlink_do']}: {dst}")
    if not dry:
        try:
            dst.unlink(missing_ok=True)
        except TypeError:
            # Python<3.8 兼容
            if dst.exists() or os.path.lexists(dst):
                dst.unlink()

def check_status(src: Path, dst: Path) -> str:
    if not src.exists(): return "MISSING_SRC"
    if not (dst.exists() or os.path.lexists(dst)):
        return "MISSING_DST"
    if not dst.is_symlink():
        return "NOT_SYMLINK"
    target = resolve_link_target(dst)
    if target is None: return "BROKEN_LINK"
    return "OK" if target.resolve() == src.resolve() else "WRONG_TARGET"

def prune_broken(dst: Path, *, dry: bool, log: Logger, T: dict):
    if dst.is_symlink():
        target = resolve_link_target(dst)
        if target is None or not target.exists():
            log.info(f"{T['remove']}: {dst} ({T['broken_link']})")
            if not dry: dst.unlink(missing_ok=True)

# -------- main --------
def main():
    # 先探测语言，便于显示本地化的 help
    pre = argparse.ArgumentParser(add_help=False)
    pre.add_argument("--lang", default=None)
    pre_args, _ = pre.parse_known_args()
    lang = detect_lang(pre_args.lang)
    T = TEXT[lang]

    ap = argparse.ArgumentParser(description=T["desc"])
    ap.add_argument("config_file", nargs="?", help=T["cfg_arg"])
    ap.add_argument("-n", "--dry-run", action="store_true", help=T["dry"])
    ap.add_argument("-f", "--force", action="store_true", help=T["force"])
    ap.add_argument("-b", "--backup-dir", default=str(Path.home()/".dotfiles-backup"), help=T["backup"])

    # 软链形式：默认相对，可用 --absolute 改为绝对；兼容旧参数 --relative
    form = ap.add_mutually_exclusive_group()
    form.add_argument("-r", "--relative", action="store_true", help=T["relative"])  # 兼容性开关（默认就是相对）
    form.add_argument("-A", "--absolute", action="store_true", help=T["absolute"])

    ap.add_argument("-u", "--unlink", action="store_true", help=T["unlink"])
    ap.add_argument("--unsafe-unlink", action="store_true", help=T["unsafe_unlink"])
    ap.add_argument("-k", "--keep-going", action="store_true", help=T["keep"])
    ap.add_argument("-H", "--help-template", action="store_true", help=T["tmpl"])
    ap.add_argument("--lang", default=None, help=T["lang"])
    ap.add_argument("--only", action="append", default=[], help=T["only"])
    ap.add_argument("--exclude", action="append", default=[], help=T["exclude"])
    ap.add_argument("--check", action="store_true", help=T["check"])
    ap.add_argument("--prune-broken", action="store_true", help=T["prune_broken"])
    ap.add_argument("--normalize", action="store_true", help=T["normalize"])
    args = ap.parse_args()

    lang = detect_lang(args.lang); T = TEXT[lang]; log = Logger()

    if args.help_template:
        print(json.dumps(generate_template(), indent=2, ensure_ascii=False)); return

    if not args.config_file:
        ap.print_help(); sys.exit(2)

    cfg = abspath_expand(args.config_file)
    if not cfg.exists(): log.err(f"{T['missing_cfg']}: {cfg}"); sys.exit(1)

    try:
        pairs = load_pairs(cfg, T)
    except Exception as e:
        log.err(str(e)); sys.exit(1)

    # 仓库根（以配置文件所在目录为根）
    repo_root = cfg.parent.resolve()

    backup_dir: Optional[Path] = None
    if args.backup_dir: backup_dir = abspath_expand(args.backup_dir)

    def filtered(pairs):
        for src, dst in pairs:
            dst_s = abspath_expand(dst).as_posix()
            if args.only and not any(dst_s.startswith(abspath_expand(p).as_posix()) for p in args.only):
                continue
            if args.exclude and any(dst_s.startswith(abspath_expand(p).as_posix()) for p in args.exclude):
                continue
            yield src, dst

    errors: List[str] = []
    # 选择软链形式：默认相对；显式 --absolute 则使用绝对
    make_relative = not args.absolute

    for src, dst in filtered(pairs):
        src_p, dst_p = abspath_expand(src), abspath_expand(dst)
        try:
            if args.check:
                st = check_status(src_p, dst_p)
                tag = {
                    "OK": T["ok"],
                    "MISSING_SRC": T["missing_src"],
                    "MISSING_DST": T["missing_dst"],
                    "WRONG_TARGET": T["wrong_target"],
                    "NOT_SYMLINK": T["not_symlink"],
                    "BROKEN_LINK": T["broken_link"],
                }[st]
                print(f"[{T['status']}] {dst_p} -> {src_p} : {tag}")
            elif args.prune_broken:
                prune_broken(dst_p, dry=args.dry_run, log=log, T=T)
            elif args.unlink:
                safe_unlink(dst_p, src_p, repo_root, dry=args.dry_run,
                            unsafe=args.unsafe_unlink, log=log, T=T)
            else:
                # normalize：目标正确但形式不同，也按指定形式重建
                if args.normalize and dst_p.is_symlink():
                    cur = resolve_link_target(dst_p)
                    if cur is not None and cur.resolve() == src_p.resolve():
                        safe_unlink(dst_p, src_p, repo_root, dry=args.dry_run,
                                    unsafe=True, log=log, T=T)
                link_one(src_p, dst_p, dry=args.dry_run, force=args.force,
                         backup_dir=backup_dir, make_relative=make_relative,
                         log=log, T=T)
        except Exception as e:
            msg = f"{dst_p} -> {src_p}: {e}"
            if args.keep_going:
                errors.append(msg); log.err(msg); continue
            else:
                log.err(msg); sys.exit(1)

    if errors:
        log.warn(T["summary_errors"])
        for m in errors: log.warn(f"  - {m}")
        sys.exit(1)
    if not args.check and not args.prune_broken:
        log.info(T["done"])

if __name__ == "__main__":
    main()
